fix(mcp): make stateless_http configurable and session-capable by default - #230
fix(mcp): make stateless_http configurable and session-capable by default#230street1983nk wants to merge 1 commit into
Conversation
…ault Clients built on the MCP SDK >= 1.28 keep the session that initialize creates. With stateless_http=True the fastmcp transport throws that session away per request, so the next call fails with "Session terminated" and both server-to-client channels are gone. Default to a session-capable transport, which is what those clients expect, and keep the previous behaviour available for deployments that want it by setting MCP_STATELESS_HTTP=1. Fixes nextcloud#227 Signed-off-by: street1983nk <k.cherif@outlook.de>
|
Hi! Thank you for taking the time to submit this PR.
Also, there are several flaws with this supposed fix: Unbounded session/task leak (high) — main.py:4425 abandoned initialize POSTs against a stateful server left 25 entries in StreamableHTTPSessionManager._server_instances and 25 live tasks, none reclaimed. fastmcp 2.13.0.2 never passes session_idle_timeout, so the SDK's idle reaper is inert and a client-sent DELETE is the only cleanup path. The ExApp is one long-lived uvicorn process, so sessions accumulate for the container's lifetime. Stateless mode called terminate() after every request — this failure mode is new. Each client pins a Nextcloud PHP worker (high) — main.py:44Request logs for a full session: stateless → POST ×5; stateful → POST, POST, GET, POST, POST, POST, DELETE. That GET is the standalone SSE stream; handle_get_stream short-circuits only when session_id is None. ExAppProxyController::ExAppGet proxies with 'stream' => true and TIMEOUT => 0, so one PHP-FPM worker is held for each MCP client's connection lifetime. A few persistent clients (Claude Code, Cursor) can exhaust the pool and take down the instance, not just the ExApp. The opt-out is unreachable (medium) — main.py:43MCP_STATELESS_HTTP isn't declared in appinfo/info.xml under . AppAPI only plumbs declared variables (ExAppService.php:301-322 — occ app_api:app:register --env applies a value only if (array_key_exists($key, $envVars))), so it's silently dropped. The PR body's "nobody loses the old behaviour" doesn't hold without a matching info.xml change. |
|
Hi Marcel, thank you for the thorough review. You are right, and I want to be upfront about it: I re-verified every point against the pinned versions (fastmcp 2.14.7, mcp 1.29.0) including live reproductions, and my root-cause analysis in this PR was wrong. What I found when re-testing:
Given all that, flipping the default to stateful would have made things worse, not better. I am closing this PR. If it helps, I can follow up in #227 with the prepareProxy 404 reason from a live setup so the actual proxy-layer cause gets pinned down. Thanks again for taking the time to lay this out in such detail. |
Problem
Clients built on the MCP Python SDK
>= 1.28(Claude Code, Hermes Agent, Cursor and otherStreamable HTTP clients) cannot stay connected to the context_agent MCP server. The
initializecall succeeds and the tools are discovered, but the next request fails:Cause
ex_app/lib/main.pymounts the MCP app with a stateless transport:With fastmcp 2.14.7 that flag makes the session manager build a throwaway transport per
request and call
terminate()once the request is answered(
StreamableHTTPSessionManager._handle_stateless_request). Every POST becomes anindependent transaction, so a client that keeps the
ClientSessionit created duringinitializeis talking to a session that no longer exists.The same setting also costs both server to client channels on that leg: server initiated
requests raise
NoBackChannelErrorand notifications are dropped silently.Fix
One functional change, no other scope:
osis already imported in that module, so the diff stays at one file and four lines.The call keeps the existing fastmcp 2.14.7
http_appsignature, so this is independent ofthe pending fastmcp 3.x bump in #177.
Backwards compatibility
The default flips from stateless to session-capable, which is what fixes the bug. Nobody
loses the old behaviour: a deployment that deliberately wants a stateless transport, for
example to spread the legacy leg over several workers without sticky routing, sets
MCP_STATELESS_HTTP=1and gets exactly what it has today. Session-capable transports keeptheir session state in process, so a multi worker deployment that does not set the variable
needs sticky routing.
Reproduction
The failure only shows with a client on the 1.x SDK line, because a
mcp >= 2client usingthe 2026-07-28 protocol era is sessionless by construction and never reaches the code path
that reads
stateless_http. That asymmetry is why the server looks healthy in some setups.Run context_agent (2.8.0 or current
main) on a Nextcloud instance and note theStreamable HTTP endpoint URL your MCP client is configured with.
Save this as
legacy_client_check.py:Run it against the endpoint with the legacy SDK pinned into an isolated environment:
Before the fix:
initializereturns, thentools/listraisesMcpError: Session terminatedand the script exits non zero.
After the fix (or with
MCP_STATELESS_HTTPunset on a patched deployment): the script printsthe number of tools and exits 0. Setting
MCP_STATELESS_HTTP=1reproduces the old failure,which is a convenient way to confirm that the switch really is the cause.
Where the automated regression test lives
Automating this inside this repository would need a second client environment on the 1.x SDK
line talking to a running ExApp container, on top of an already heavy server version matrix
(master, stable33, stable32, stable31 plus the llm2 app). That would add a lot of CI weight
and flakiness for one flag, so the check is automated in our project instead, and it is the
source of the reproduction above:
tests/compat/legacy_client_check.pyperformsinitializeplustools/listundermcp>=1.29,<2in its own environment and exits 1 on "Session terminated".tests/compat/test_client_matrix.pyruns that legacy client and amcp>=2,<3clientagainst the same endpoint, so a stateless transport regression fails the build.
Happy to switch this to a plain
stateless_http=Falsewithout the environment variable ifyou prefer the smaller surface.
Fixes #227